/*
 * Copyright 2014 James Moger.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.iciql;

import com.iciql.Iciql.DataTypeAdapter;
import com.iciql.util.JdbcUtils;
import com.iciql.util.StringUtils;
import com.iciql.util.Utils;

import java.lang.annotation.Annotation;
import java.lang.reflect.Array;
import java.lang.reflect.Constructor;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Proxy;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.TreeMap;
import java.util.concurrent.ConcurrentHashMap;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * DaoProxy creates a dynamic instance of the provided Dao interface.
 *
 * @param <X>
 * @author James Moger
 */
final class DaoProxy<X extends Dao> implements InvocationHandler, Dao {

    private final Db db;

    private final Class<X> daoInterface;

    private final char bindingDelimiter = ':';

    private final Map<Method, IndexedSql> indexedSqlCache;

    DaoProxy(Db db, Class<X> daoInterface) {
        this.db = db;
        this.daoInterface = daoInterface;
        this.indexedSqlCache = new ConcurrentHashMap<Method, IndexedSql>();
    }

    /**
     * Builds a proxy object for the DAO interface.
     *
     * @return a proxy object
     */
    @SuppressWarnings("unchecked")
    X build() {

        if (!daoInterface.isInterface()) {
            throw new IciqlException("Dao {0} must be an interface!", daoInterface.getName());
        }

        ClassLoader classLoader = daoInterface.getClassLoader();

        Set<Class<?>> interfaces = new HashSet<Class<?>>();
        interfaces.add(Dao.class);
        interfaces.add(daoInterface);
        for (Class<?> clazz : daoInterface.getInterfaces()) {
            interfaces.add(clazz);
        }

        Class<?>[] constructorParams = {InvocationHandler.class};
        Class<?>[] allInterfaces = interfaces.toArray(new Class<?>[interfaces.size()]);

        try {

            Class<?> proxyClass = Proxy.getProxyClass(classLoader, allInterfaces);
            Constructor<?> proxyConstructor = proxyClass.getConstructor(constructorParams);
            return (X) proxyConstructor.newInstance(new Object[]{this});

        } catch (Exception e) {
            throw new IciqlException(e);
        }
    }

    /**
     * Invoke intercepts method calls and delegates execution to the appropriate object.
     */
    @Override
    public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
        try {

            if (method.getDeclaringClass() == Dao.class) {

                return method.invoke(this, args);

            } else if (method.isAnnotationPresent(SqlQuery.class)) {

                String sql = method.getAnnotation(SqlQuery.class).value();
                String statement = db.getDaoStatementProvider().getStatement(sql, db.getMode());
                return executeQuery(method, args, statement);

            } else if (method.isAnnotationPresent(SqlStatement.class)) {

                String sql = method.getAnnotation(SqlStatement.class).value();
                String statement = db.getDaoStatementProvider().getStatement(sql, db.getMode());
                return executeStatement(method, args, statement);

            } else {

                throw new IciqlException("Can not invoke non-dao method {0}.{1}",
                        method.getDeclaringClass().getSimpleName(), method.getName());

            }

        } catch (InvocationTargetException te) {
            throw te.getCause();
        }
    }

    /**
     * Execute a query.
     *
     * @param method
     * @param methodArgs
     * @param sql
     * @return the result
     */
    private Object executeQuery(Method method, Object[] methodArgs, String sql) {

		/*
         * Determine and validate the return type
		 */
        Class<?> returnType = method.getReturnType();

        if (void.class == returnType) {
            throw new IciqlException("You must specify a return type for @{0} {1}.{2}!",
                    SqlQuery.class.getSimpleName(), method.getDeclaringClass().getSimpleName(), method.getName());
        }

        if (Collection.class.isAssignableFrom(returnType)) {
            throw new IciqlException("You may not return a collection for an @{0} method, please change the return type of {1}.{2} to YourClass[]!",
                    SqlQuery.class.getSimpleName(), method.getDeclaringClass().getSimpleName(), method.getName());
        }

        boolean isArray = false;
        if (returnType.isArray()) {
            isArray = true;
            returnType = returnType.getComponentType();
        }

        boolean isJavaType = returnType.isEnum()
                || returnType.isPrimitive()
                || java.lang.Boolean.class.isAssignableFrom(returnType)
                || java.lang.Number.class.isAssignableFrom(returnType)
                || java.lang.String.class.isAssignableFrom(returnType)
                || java.util.Date.class.isAssignableFrom(returnType)
                || byte[].class.isAssignableFrom(returnType);

        Class<? extends DataTypeAdapter<?>> adapter = Utils.getDataTypeAdapter(method.getAnnotations());
        if (adapter == null) {
            adapter = Utils.getDataTypeAdapter(returnType.getAnnotations());
        }

		/*
		 * Prepare & execute sql
		 */
        PreparedSql preparedSql = prepareSql(method, methodArgs, sql);

        List<Object> objects;
        if (!isJavaType && adapter == null) {

            // query of an Iciql model
            objects = db.executeQuery(returnType, preparedSql.sql, preparedSql.parameters);

        } else {

            // query of (array of) standard Java type or a DataTypeAdapter type
            objects = Utils.newArrayList();
            ResultSet rs = db.executeQuery(preparedSql.sql, preparedSql.parameters);
            try {

                while (rs.next()) {

                    Object value = db.getDialect().deserialize(rs, 1, returnType, adapter);
                    objects.add(value);

                    if (!isArray) {
                        // we are not returning an array so we break
                        // the loop and return the first result
                        break;
                    }
                }

            } catch (SQLException e) {
                throw new IciqlException(e);
            } finally {
                JdbcUtils.closeSilently(rs);
            }

        }

		/*
		 * Return the results
		 */
        if (objects == null || objects.isEmpty()) {

            // no results
            if (isArray) {
                // return an empty array
                return Array.newInstance(returnType, 0);
            }

            // nothing to return!
            return null;

        } else if (isArray) {

            // return an array of object results
            Object array = Array.newInstance(returnType, objects.size());
            for (int i = 0; i < objects.size(); i++) {
                Array.set(array, i, objects.get(i));
            }
            return array;

        }

        // return first element
        return objects.get(0);
    }


    /**
     * Execute a statement.
     *
     * @param method
     * @param methodArgs
     * @param sql
     * @return the result
     */
    private Object executeStatement(Method method, Object[] methodArgs, String sql) {

		/*
		 * Determine and validate the return type
		 */
        Class<?> returnType = method.getReturnType();

        if (void.class != returnType && boolean.class != returnType && int.class != returnType) {

            throw new IciqlException("Invalid return type '{0}' for @{1} {2}.{3}!",
                    returnType.getSimpleName(), SqlQuery.class.getSimpleName(),
                    method.getDeclaringClass().getSimpleName(), method.getName());
        }

		/*
		 * Prepare & execute sql
		 */
        PreparedSql preparedSql = prepareSql(method, methodArgs, sql);
        int rows = db.executeUpdate(preparedSql.sql, preparedSql.parameters);

		/*
		 * Return the results
		 */
        if (void.class == returnType) {

            // return nothing
            return null;

        } else if (boolean.class == returnType) {

            // return true if any rows were affected
            return rows > 0;

        } else {

            // return number of rows
            return rows;

        }
    }

    /**
     * Prepares an sql statement and execution parameters based on the supplied
     * method and it's arguments.
     *
     * @param method
     * @param methodArgs
     * @param sql
     * @return a prepared sql statement and arguments
     */
    private PreparedSql prepareSql(Method method, Object[] methodArgs, String sql) {

        if (methodArgs == null || methodArgs.length == 0) {
            // no method arguments
            return new PreparedSql(sql, null);
        }

        IndexedSql indexedSql = indexedSqlCache.get(method);

        if (indexedSql == null) {

            // index the sql and method args
            indexedSql = indexSql(method, sql);

            // cache the indexed sql for re-use
            indexedSqlCache.put(method, indexedSql);
        }

        final PreparedSql preparedSql = indexedSql.prepareSql(db, methodArgs);
        return preparedSql;
    }

    /**
     * Indexes an sql statement and method args based on the supplied
     * method and it's arguments.
     *
     * @param method
     * @param sql
     * @return an indexed sql statement and arguments
     */
    private IndexedSql indexSql(Method method, String sql) {

        Map<String, IndexedArgument> parameterIndex = buildParameterIndex(method);

        // build a regex to extract parameter names from the sql statement
        StringBuilder sb = new StringBuilder();
        sb.append(bindingDelimiter);
        sb.append("{1}(\\?");
        for (String name : parameterIndex.keySet()) {
            sb.append("|");
            // strip binding delimeter from name
            sb.append(name);
        }
        sb.append(')');

        // identify parameters, replace with the '?' PreparedStatement
        // delimiter and build the PreparedStatement parameters array
        final String regex = sb.toString();
        final Pattern p = Pattern.compile(regex);
        final Matcher m = p.matcher(sql);
        final StringBuffer buffer = new StringBuffer();

        List<IndexedArgument> indexedArgs = Utils.newArrayList();
        int count = 0;
        while (m.find()) {
            String binding = m.group(1);
            m.appendReplacement(buffer, "?");

            IndexedArgument indexedArg;
            if ("?".equals(binding)) {
                // standard ? JDBC placeholder
                indexedArg = parameterIndex.get("arg" + count);
            } else {
                // named placeholder
                indexedArg = parameterIndex.get(binding);
            }

            if (indexedArg == null) {
                throw new IciqlException("Unbound SQL parameter '{0}' in {1}.{2}",
                        binding, method.getDeclaringClass().getSimpleName(), method.getName());
            }
            indexedArgs.add(indexedArg);

            count++;
        }
        m.appendTail(buffer);

        final String statement = buffer.toString();

        // create an IndexedSql container for the statement and indexes
        return new IndexedSql(statement, Collections.unmodifiableList(indexedArgs));

    }

    /**
     * Builds an index of parameter name->(position,typeAdapter) from the method arguments
     * array. This index is calculated once per method.
     *
     * @param method
     * @return a bindings map of ("name", IndexedArgument) pairs
     */
    private Map<String, IndexedArgument> buildParameterIndex(Method method) {

        Map<String, IndexedArgument> index = new TreeMap<String, IndexedArgument>();

        Annotation[][] annotationsMatrix = method.getParameterAnnotations();
        for (int i = 0; i < annotationsMatrix.length; i++) {

            Annotation[] annotations = annotationsMatrix[i];

			/*
			 * Conditionally map the bean properties of the method argument
			 * class to Method and Field instances.
			 */
            BindBean bean = getAnnotation(BindBean.class, annotations);
            if (bean != null) {
                final String prefix = bean.value();
                final Class<?> argumentClass = method.getParameterTypes()[i];
                Map<String, IndexedArgument> beanIndex = buildBeanIndex(i, prefix, argumentClass);
                index.putAll(beanIndex);
            }

            Class<? extends DataTypeAdapter<?>> typeAdapter = Utils.getDataTypeAdapter(annotations);
            final IndexedArgument indexedArgument = new IndexedArgument(i, typeAdapter);

            // :N - 1-indexed, like JDBC ResultSet
            index.put("" + (i + 1), indexedArgument);

            // argN - 0-indexed, like Reflection
            index.put("arg" + i, indexedArgument);

            // Bound name
            Bind binding = getAnnotation(Bind.class, annotations);
            if (binding != null && !binding.value().isEmpty()) {
                index.put(binding.value(), indexedArgument);
            }

            // try mapping Java 8 argument names, may overwrite argN
            try {
                Class<?> nullArgs = null;
                Method getParameters = method.getClass().getMethod("getParameters", nullArgs);
                if (getParameters != null) {
                    Object[] parameters = (Object[]) getParameters.invoke(method, nullArgs);
                    if (parameters != null) {
                        Object o = parameters[i];
                        Method getName = o.getClass().getMethod("getName", nullArgs);
                        String j8name = getName.invoke(o, nullArgs).toString();
                        if (!j8name.isEmpty()) {
                            index.put(j8name, indexedArgument);
                        }
                    }
                }
            } catch (Throwable t) {
            }
        }

        return index;
    }

    /**
     * Builds an index of parameter name->(position,method) from the method arguments
     * array. This index is calculated once per method.
     *
     * @param argumentIndex
     * @param prefix
     * @param beanClass
     * @return a bindings map of ("prefix.property", IndexedArgument) pairs
     */
    private Map<String, IndexedArgument> buildBeanIndex(int argumentIndex, String prefix, Class<?> beanClass) {

        final String beanPrefix = StringUtils.isNullOrEmpty(prefix) ? "" : (prefix + ".");
        final Map<String, IndexedArgument> index = new TreeMap<String, IndexedArgument>();

        // map JavaBean property getters
        for (Method method : beanClass.getMethods()) {

            if (Modifier.isStatic(method.getModifiers())
                    || method.getReturnType() == void.class
                    || method.getParameterTypes().length > 0
                    || method.getDeclaringClass() == Object.class) {

                // not a JavaBean property
                continue;
            }

            final String propertyName;
            final String name = method.getName();
            if (name.startsWith("get")) {
                propertyName = method.getName().substring(3);
            } else if (name.startsWith("is")) {
                propertyName = method.getName().substring(2);
            } else {
                propertyName = null;
            }

            if (propertyName == null) {
                // not a conventional JavaBean property
                continue;
            }

            final String binding = beanPrefix + preparePropertyName(propertyName);
            final IndexedArgument indexedArg = new IndexedArgument(argumentIndex, method);

            index.put(binding, indexedArg);
        }

        // map public instance fields
        for (Field field : beanClass.getFields()) {

            if (Modifier.isStatic(field.getModifiers())) {
                // not a JavaBean property
                continue;
            }

            final String binding = beanPrefix + preparePropertyName(field.getName());
            final IndexedArgument indexedArg = new IndexedArgument(argumentIndex, field);

            index.put(binding, indexedArg);

        }

        return index;
    }

    @SuppressWarnings("unchecked")
    private <T> T getAnnotation(Class<T> annotationClass, Annotation[] annotations) {
        if (annotations != null) {
            for (Annotation annotation : annotations) {
                if (annotation.annotationType() == annotationClass) {
                    return (T) annotation;
                }
            }
        }
        return null;
    }

    private String preparePropertyName(String value) {
        return Character.toLowerCase(value.charAt(0)) + value.substring(1);
    }

	/*
	 *
	 * Standard Dao method implementations delegate to the underlying Db
	 *
	 */

    @Override
    public final Db db() {
        return db;
    }

    @Override
    public final <T> boolean insert(T t) {
        return db.insert(t);
    }

    @Override
    public final <T> void insertAll(List<T> t) {
        db.insertAll(t);
    }

    @Override
    public final <T> long insertAndGetKey(T t) {
        return db.insertAndGetKey(t);
    }

    @Override
    public final <T> List<Long> insertAllAndGetKeys(List<T> t) {
        return db.insertAllAndGetKeys(t);
    }

    @Override
    public final <T> boolean update(T t) {
        return db.update(t);
    }

    @Override
    public final <T> void updateAll(List<T> t) {
        db.updateAll(t);
    }

    @Override
    public final <T> void merge(T t) {
        db.merge(t);
    }

    @Override
    public final <T> boolean delete(T t) {
        return db.delete(t);
    }

    @Override
    public final <T> void deleteAll(List<T> t) {
        db.deleteAll(t);
    }

    @Override
    public final void close() {
        db.close();
    }

    /**
     * Container class to hold the prepared JDBC SQL statement and execution
     * parameters.
     */
    private class PreparedSql {
        final String sql;
        final Object[] parameters;

        PreparedSql(String sql, Object[] parameters) {
            this.sql = sql;
            this.parameters = parameters;
        }

        @Override
        public String toString() {
            return sql;
        }

    }

    /**
     * Container class to hold a parsed JDBC SQL statement and
     * IndexedParameters.
     * <p>
     * Instances of this class are cached because they are functional processing
     * containers as they contain Method and Field references for binding beans
     * and matching to method arguments.
     * </p>
     */
    private class IndexedSql {
        final String sql;
        final List<IndexedArgument> indexedArgs;

        IndexedSql(String sql, List<IndexedArgument> indexedArgs) {
            this.sql = sql;
            this.indexedArgs = indexedArgs;
        }

        /**
         * Prepares the method arguments for statement execution.
         *
         * @param db
         * @param methodArgs
         * @return the prepared sql statement and parameters
         */
        PreparedSql prepareSql(Db db, Object[] methodArgs) {

            Object[] parameters = new Object[indexedArgs.size()];

            for (int i = 0; i < indexedArgs.size(); i++) {

                IndexedArgument indexedArg = indexedArgs.get(i);
                Object methodArg = methodArgs[indexedArg.index];

                Object value = methodArg;
                Class<? extends DataTypeAdapter<?>> typeAdapter = indexedArg.typeAdapter;

                if (indexedArg.method != null) {

                    // execute the bean method
                    try {

                        value = indexedArg.method.invoke(methodArg);
                        typeAdapter = Utils.getDataTypeAdapter(indexedArg.method.getAnnotations());

                    } catch (Exception e) {
                        throw new IciqlException(e);
                    }

                } else if (indexedArg.field != null) {

                    // extract the field value
                    try {

                        value = indexedArg.field.get(methodArg);
                        typeAdapter = Utils.getDataTypeAdapter(indexedArg.field.getAnnotations());

                    } catch (Exception e) {
                        throw new IciqlException(e);
                    }

                } else if (typeAdapter == null) {

                    // identify the type adapter for the argument class
                    typeAdapter = Utils.getDataTypeAdapter(methodArg.getClass().getAnnotations());
                }

                // prepare the parameter
                parameters[i] = db.getDialect().serialize(value, typeAdapter);

            }

            return new PreparedSql(sql, parameters);

        }

        @Override
        public String toString() {
            return sql;
        }
    }

    /**
     * IndexedArgument holds cached information about how to process an method
     * argument by it's index in the method arguments array.
     * <p>
     * An argument may be passed-through, might be bound to a bean property,
     * might be transformed with a type adapter, or a combination of these.
     * </p>
     */
    private class IndexedArgument {
        final int index;
        final Class<? extends DataTypeAdapter<?>> typeAdapter;
        final Method method;
        final Field field;

        IndexedArgument(int index, Class<? extends DataTypeAdapter<?>> typeAdapter) {
            this.index = index;
            this.typeAdapter = typeAdapter;
            this.method = null;
            this.field = null;
        }

        IndexedArgument(int methodArgIndex, Method method) {
            this.index = methodArgIndex;
            this.typeAdapter = null;
            this.method = method;
            this.field = null;
        }

        IndexedArgument(int methodArgIndex, Field field) {
            this.index = methodArgIndex;
            this.typeAdapter = null;
            this.method = null;
            this.field = field;
        }

        @Override
        public String toString() {

            String accessor;
            if (method != null) {
                accessor = "M:" + method.getDeclaringClass().getSimpleName() + "." + method.getName();
            } else if (field != null) {
                accessor = "F:" + field.getDeclaringClass().getSimpleName() + "." + field.getName();
            } else {
                accessor = "A:arg";
            }

            return index + ":" + accessor + (typeAdapter == null ? "" : (":" + typeAdapter.getSimpleName()));
        }

    }

}
